返回 筑基・Web 道途启关

19 我的日志切面,让老板直接看到了员工的薪资

博主
大约 17 分钟

19 我的日志切面,让老板直接看到了员工的薪资

周五下午,老板突然把我叫到办公室:"为什么日志里能看到所有人的工资?"我看着生产环境日志里明晃晃的用户薪资: 25000.00,冷汗瞬间湿透了后背。那一刻我才明白:AOP不是魔法棒,用错了会泄露机密。

image-20260202134747143

一、Spring AOP:从"神奇魔法"到"理解代价"

1.1 我第一次用AOP的"惨痛教训"

我写了一个"完美"的日志切面:

java

@Aspect
@Component
@Slf4j
public class LoggingAspect {
    
    @Around("execution(* com.company..*.*(..))")
    public Object logAllMethods(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().toShortString();
        
        // 记录入参
        Object[] args = joinPoint.getArgs();
        log.info("【调用开始】{} 参数: {}", methodName, Arrays.toString(args));
        
        try {
            Object result = joinPoint.proceed();
            
            // 记录返回值 - 这就是灾难的开始
            log.info("【调用成功】{} 返回值: {}", methodName, result);
            return result;
        } catch (Exception e) {
            log.error("【调用失败】{} 异常: {}", methodName, e.getMessage(), e);
            throw e;
        }
    }
}

看起来很好,对吧?直到我看到生产日志:

text

【调用成功】SalaryService.getSalary(..) 返回值: SalaryDTO(id=123, employeeId=456, amount=25000.00, month=2023-10)

问题

  1. 敏感信息泄露:薪资是最高机密,怎么能出现在日志里?
  2. 日志爆炸:每个方法调用都记录,一天产生100GB日志
  3. 性能影响:序列化大对象(如包含1000个元素的List)极其耗时

1.2 重构后的安全切面

导师教我如何正确设计切面:

image-20260202135105214

java

@Aspect
@Component
@Slf4j
public class SafeLoggingAspect {
    
    // 1. 使用注解精确控制,而不是拦截所有方法
    @Pointcut("@annotation(com.company.annotation.OperationLog)")
    public void operationLogPointcut() {}
    
    // 2. 使用SpEL表达式动态获取日志内容
    @Around("@annotation(operationLog)")
    public Object logOperation(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable {
        long startTime = System.currentTimeMillis();
        String operation = operationLog.value();
        
        // 只记录必要信息,不记录敏感参数
        log.debug("【操作开始】{}", operation);
        
        try {
            Object result = joinPoint.proceed();
            long costTime = System.currentTimeMillis() - startTime;
            
            // 3. 使用脱敏工具处理返回值
            String resultLog = safeLogResult(result, operationLog.maskFields());
            
            // 4. 根据日志级别动态输出
            if (costTime > 1000) {
                log.warn("【操作完成-慢查询】{} 耗时: {}ms 结果: {}", 
                        operation, costTime, resultLog);
            } else {
                log.debug("【操作完成】{} 耗时: {}ms", operation, costTime);
            }
            
            return result;
        } catch (Exception e) {
            long costTime = System.currentTimeMillis() - startTime;
            
            // 5. 区分业务异常和系统异常
            if (e instanceof BusinessException) {
                log.warn("【操作失败-业务异常】{} 耗时: {}ms 原因: {}", 
                        operation, costTime, e.getMessage());
            } else {
                log.error("【操作失败-系统异常】{} 耗时: {}ms", 
                        operation, costTime, e);
            }
            
            throw e;
        }
    }
    
    private String safeLogResult(Object result, String[] maskFields) {
        if (result == null) {
            return "null";
        }
        
        // 如果是集合或数组,只记录大小,不记录内容
        if (result instanceof Collection) {
            return String.format("Collection[size=%d]", ((Collection<?>) result).size());
        }
        
        if (result.getClass().isArray()) {
            return String.format("Array[length=%d]", Array.getLength(result));
        }
        
        // 使用脱敏工具
        try {
            return MaskUtil.maskFields(result, maskFields);
        } catch (Exception e) {
            // 脱敏失败时,只记录类型信息
            return String.format("%s[masked]", result.getClass().getSimpleName());
        }
    }
}

// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
    String value();  // 操作描述
    String[] maskFields() default {};  // 需要脱敏的字段
}

// 使用示例
@RestController
@RequestMapping("/salary")
public class SalaryController {
    
    @OperationLog(value = "查询员工薪资", maskFields = {"amount", "bankCardNo"})
    @GetMapping("/{employeeId}")
    public SalaryDTO getSalary(@PathVariable String employeeId) {
        return salaryService.getSalary(employeeId);
    }
}

二、Spring Boot自动配置:从"黑盒魔法"到"透明理解"

2.1 那个让我debug三天的配置问题

image-20260202160440528

我的项目突然启动失败:

text

Parameter 0 of method redisTemplate in RedisAutoConfiguration required a single bean, but 2 were found:
- lettuceConnectionFactory
- jedisConnectionFactory

我既没有配置Lettuce,也没有配置Jedis。为什么会这样?

2.2 理解自动配置的原理

导师让我打开spring-boot-autoconfigure包的源码:

java

// RedisAutoConfiguration.java的关键部分
@Configuration
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {
    
    @Bean
    @ConditionalOnMissingBean(name = "redisTemplate")
    public RedisTemplate<Object, Object> redisTemplate(
            RedisConnectionFactory redisConnectionFactory) {
        // ...
    }
}

java

// LettuceConnectionConfiguration.java
@Configuration
@ConditionalOnClass(RedisClient.class)
@ConditionalOnProperty(name = "spring.redis.client-type", havingValue = "lettuce", matchIfMissing = true)
class LettuceConnectionConfiguration {
    // 当有RedisClient类,且没有指定client-type或指定为lettuce时生效
}

// JedisConnectionConfiguration.java  
@Configuration
@ConditionalOnClass({GenericObjectPool.class, JedisConnection.class, Jedis.class})
@ConditionalOnProperty(name = "spring.redis.client-type", havingValue = "jedis")
class JedisConnectionConfiguration {
    // 当有Jedis相关类,且明确指定client-type为jedis时生效
}

问题根源:我的项目中同时引入了Lettuce和Jedis的依赖,Spring Boot无法自动选择。

解决方案

yaml

# application.yml
spring:
  redis:
    client-type: lettuce  # 明确指定使用lettuce

或者排除不需要的依赖:

xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>

2.3 自定义自动配置的实战

有一次,我们需要为所有微服务提供统一的ID生成器(雪花算法)。我写了一个自定义starter:

第一步:创建starter项目结构

text

snowflake-spring-boot-starter/
├── pom.xml
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/
│   │   │       └── company/
│   │   │           └── snowflake/
│   │   │               ├── SnowflakeProperties.java
│   │   │               ├── SnowflakeAutoConfiguration.java
│   │   │               └── IdGenerator.java
│   │   └── resources/
│   │       └── META-INF/
│   │           └── spring/
│   │               └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
│   └── test/

第二步:实现核心代码

java

// 配置属性类
@ConfigurationProperties(prefix = "snowflake")
@Data
public class SnowflakeProperties {
    /**
     * 工作节点ID (0-31)
     */
    private long workerId = 0;
    
    /**
     * 数据中心ID (0-31)
     */
    private long datacenterId = 0;
    
    /**
     * 是否启用
     */
    private boolean enabled = true;
    
    /**
     * 序列号位数
     */
    private long sequenceBits = 12L;
    
    /**
     * 工作节点位数
     */
    private long workerIdBits = 5L;
    
    /**
     * 数据中心位数
     */
    private long datacenterIdBits = 5L;
}

// ID生成器
@Component
@ConditionalOnProperty(prefix = "snowflake", name = "enabled", havingValue = "true")
public class IdGenerator {
    private final SnowflakeProperties properties;
    
    // 起始时间戳(2023-01-01)
    private final long twepoch = 1672531200000L;
    
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    public IdGenerator(SnowflakeProperties properties) {
        this.properties = properties;
        validateConfiguration();
    }
    
    private void validateConfiguration() {
        long maxWorkerId = ~(-1L << properties.getWorkerIdBits());
        if (properties.getWorkerId() > maxWorkerId || properties.getWorkerId() < 0) {
            throw new IllegalArgumentException(
                String.format("workerId不能大于%d或小于0", maxWorkerId));
        }
        
        long maxDatacenterId = ~(-1L << properties.getDatacenterIdBits());
        if (properties.getDatacenterId() > maxDatacenterId || properties.getDatacenterId() < 0) {
            throw new IllegalArgumentException(
                String.format("datacenterId不能大于%d或小于0", maxDatacenterId));
        }
    }
    
    public synchronized long nextId() {
        long timestamp = timeGen();
        
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(
                String.format("时钟回拨拒绝生成ID。回拨时间: %dms", 
                lastTimestamp - timestamp));
        }
        
        if (lastTimestamp == timestamp) {
            long sequenceMask = ~(-1L << properties.getSequenceBits());
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        
        lastTimestamp = timestamp;
        
        long timestampLeftShift = properties.getSequenceBits() + 
                                  properties.getWorkerIdBits() + 
                                  properties.getDatacenterIdBits();
        long datacenterIdShift = properties.getSequenceBits() + 
                                 properties.getWorkerIdBits();
        
        return ((timestamp - twepoch) << timestampLeftShift)
                | (properties.getDatacenterId() << datacenterIdShift)
                | (properties.getWorkerId() << properties.getSequenceBits())
                | sequence;
    }
    
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }
    
    private long timeGen() {
        return System.currentTimeMillis();
    }
}

// 自动配置类
@Configuration
@ConditionalOnClass(IdGenerator.class)
@EnableConfigurationProperties(SnowflakeProperties.class)
@AutoConfigureAfter(DataSourceAutoConfiguration.class) // 在数据源配置之后
public class SnowflakeAutoConfiguration {
    
    private static final Logger log = LoggerFactory.getLogger(SnowflakeAutoConfiguration.class);
    
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "snowflake", name = "enabled", havingValue = "true")
    public IdGenerator idGenerator(SnowflakeProperties properties) {
        log.info("初始化雪花算法ID生成器,workerId={}, datacenterId={}", 
                properties.getWorkerId(), properties.getDatacenterId());
        return new IdGenerator(properties);
    }
    
    // 提供RestTemplate的拦截器,自动为请求添加Trace ID
    @Bean
    @ConditionalOnClass(RestTemplate.class)
    @ConditionalOnMissingBean(name = "traceIdRestTemplateInterceptor")
    public ClientHttpRequestInterceptor traceIdRestTemplateInterceptor(IdGenerator idGenerator) {
        return (request, body, execution) -> {
            request.getHeaders().add("X-Trace-Id", String.valueOf(idGenerator.nextId()));
            return execution.execute(request, body);
        };
    }
}

第三步:配置自动装配

src/main/resources/META-INF/spring/下创建文件:

text

# org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.company.snowflake.SnowflakeAutoConfiguration

第四步:打包发布

xml

<!-- pom.xml -->
<project>
    <modelVersion>4.0.0</modelVersion>
    
    <groupId>com.company</groupId>
    <artifactId>snowflake-spring-boot-starter</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>
    
    <properties>
        <java.version>11</java.version>
        <spring-boot.version>2.7.15</spring-boot.version>
    </properties>
    
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    
    <dependencies>
        <!-- Spring Boot自动配置核心依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>
        
        <!-- 配置处理器,让IDE能识别配置属性 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        
        <!-- 测试依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

第五步:使用starter

其他项目只需要:

  1. 引入依赖:

xml

<dependency>
    <groupId>com.company</groupId>
    <artifactId>snowflake-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
  1. 配置参数:

yaml

snowflake:
  enabled: true
  worker-id: 1
  datacenter-id: 1
  1. 直接使用:

java

@Service
public class OrderService {
    @Autowired
    private IdGenerator idGenerator;
    
    public Order createOrder(OrderDTO dto) {
        Order order = new Order();
        order.setId(idGenerator.nextId());  // 生成分布式ID
        // ...
        return order;
    }
}

三、Maven高级:从"依赖地狱"到"精准控制"

image-20260202160822300

3.1 我遇到的依赖冲突

项目启动时:

text

java.lang.NoSuchMethodError: org.yaml.snakeyaml.LoaderOptions.setMaxAliasesForCollections(I)V

这是典型的依赖冲突。通过mvn dependency:tree查看:

text

[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:2.7.15:compile
[INFO] |  +- org.springframework.boot:spring-boot-starter:jar:2.7.15:compile
[INFO] |  |  \- org.yaml:snakeyaml:jar:1.30:compile
[INFO] +- com.thirdparty:some-lib:jar:1.0.0:compile
[INFO] |  \- org.yaml:snakeyaml:jar:1.25:compile

SnakeYAML有1.30和1.25两个版本,Maven选择了1.25(路径更短?),但这个版本没有setMaxAliasesForCollections方法。

3.2 Maven依赖调解的真相

导师教我理解Maven的依赖调解:

  1. 最短路径优先:谁离项目近就用谁
  2. 第一声明优先:路径一样长时,谁先声明就用谁
  3. 排除传递依赖:可以排除不需要的传递依赖
  4. 依赖管理:统一管理所有依赖版本

xml

<!-- 解决方案1:排除旧版本 -->
<dependency>
    <groupId>com.thirdparty</groupId>
    <artifactId>some-lib</artifactId>
    <version>1.0.0</version>
    <exclusions>
        <exclusion>
            <groupId>org.yaml</groupId>
            <artifactId>snakeyaml</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<!-- 解决方案2:统一版本管理 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.yaml</groupId>
            <artifactId>snakeyaml</artifactId>
            <version>1.30</version>
        </dependency>
    </dependencies>
</dependencyManagement>

3.3 多模块项目的最佳实践

我们有一个大型项目,拆分成多个模块:

text

company-parent/
├── pom.xml (父工程)
├── company-common/ (通用模块)
│   ├── pom.xml
│   └── src/
├── company-domain/ (领域模型)
│   ├── pom.xml
│   └── src/
├── company-repository/ (数据访问)
│   ├── pom.xml
│   └── src/
├── company-service/ (业务逻辑)
│   ├── pom.xml
│   └── src/
├── company-web/ (Web接口)
│   ├── pom.xml
│   └── src/
└── company-batch/ (批处理)
    ├── pom.xml
    └── src/

父pom的关键配置

xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    
    <modelVersion>4.0.0</modelVersion>
    
    <groupId>com.company</groupId>
    <artifactId>company-parent</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>
    
    <modules>
        <module>company-common</module>
        <module>company-domain</module>
        <module>company-repository</module>
        <module>company-service</module>
        <module>company-web</module>
        <module>company-batch</module>
    </modules>
    
    <properties>
        <java.version>11</java.version>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        
        <!-- 统一版本管理 -->
        <spring-boot.version>2.7.15</spring-boot.version>
        <mybatis.version>3.5.13</mybatis.version>
        <mysql.version>8.0.33</mysql.version>
        <jackson.version>2.14.2</jackson.version>
        <lombok.version>1.18.28</lombok.version>
    </properties>
    
    <!-- 依赖管理:统一所有子模块的依赖版本 -->
    <dependencyManagement>
        <dependencies>
            <!-- Spring Boot BOM -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            
            <!-- 其他统一管理的依赖 -->
            <dependency>
                <groupId>org.mybatis</groupId>
                <artifactId>mybatis</artifactId>
                <version>${mybatis.version}</version>
            </dependency>
            
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>${mysql.version}</version>
            </dependency>
            
            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
                <version>${jackson.version}</version>
            </dependency>
            
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>${lombok.version}</version>
                <scope>provided</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    
    <!-- 所有子模块共享的构建配置 -->
    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <version>${spring-boot.version}</version>
                    <configuration>
                        <skip>true</skip>  <!-- 子模块默认不打包为可执行jar -->
                    </configuration>
                    <executions>
                        <execution>
                            <goals>
                                <goal>repackage</goal>
                            </goals>
                        </execution>
                    </executions>
                </plugin>
                
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.11.0</version>
                    <configuration>
                        <source>${java.version}</source>
                        <target>${java.version}</target>
                        <encoding>${project.build.sourceEncoding}</encoding>
                        <annotationProcessorPaths>
                            <path>
                                <groupId>org.projectlombok</groupId>
                                <artifactId>lombok</artifactId>
                                <version>${lombok.version}</version>
                            </path>
                        </annotationProcessorPaths>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
    
    <!-- 所有子模块共享的仓库配置 -->
    <repositories>
        <repository>
            <id>aliyun</id>
            <name>Aliyun Maven Repository</name>
            <url>https://maven.aliyun.com/repository/public</url>
            <releases>
                <enabled>true</enabled>
            </releases>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>
</project>

子模块pom的配置

xml

<!-- company-service/pom.xml -->
<project>
    <parent>
        <groupId>com.company</groupId>
        <artifactId>company-parent</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    
    <modelVersion>4.0.0</modelVersion>
    <artifactId>company-service</artifactId>
    <packaging>jar</packaging>
    
    <dependencies>
        <!-- 依赖兄弟模块 -->
        <dependency>
            <groupId>com.company</groupId>
            <artifactId>company-domain</artifactId>
            <version>${project.version}</version>
        </dependency>
        
        <dependency>
            <groupId>com.company</groupId>
            <artifactId>company-repository</artifactId>
            <version>${project.version}</version>
        </dependency>
        
        <!-- 外部依赖(无需指定版本) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>
        
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
    </dependencies>
</project>

四、经验总结:进阶之路的必经之痛

image-20260202161419577

4.1 AOP的注意事项

  1. 性能影响:AOP会增加方法调用的开销,特别是环绕通知
  2. 异常处理:切面中的异常可能掩盖原始异常
  3. 代理限制:AOP无法增强私有方法、静态方法、final方法
  4. 循环依赖:AOP可能引起循环依赖问题

4.2 Spring Boot自动配置的坑

  1. 条件竞争:多个自动配置类竞争同一个Bean
  2. 配置覆盖:自定义配置可能被自动配置覆盖
  3. 启动顺序:自动配置的加载顺序不可控
  4. 诊断困难:配置不生效时,难以定位原因

4.3 Maven的最佳实践

  1. 统一版本管理:使用dependencyManagement统一版本
  2. 模块化设计:合理拆分模块,降低耦合
  3. 持续集成:配合Jenkins等工具自动化构建
  4. 依赖分析:定期使用mvn dependency:analyze分析依赖

结语:从使用者到创造者

学习这些进阶技术后,我最大的变化是:

从"框架能做什么"的思考,转变为"我需要框架做什么"的设计。

当遇到问题时,我不再只是搜索解决方案,而是思考:

  • 这个问题是偶发的还是必然的?
  • 有没有通用的解决方案?
  • 能否设计一个框架或工具来一劳永逸地解决?

更重要的是,我学会了敬畏复杂性。每一个看似简单的"魔法"背后,都是大量的设计和权衡。

记住:真正的技术高手,不是知道多少API,而是理解技术背后的原理和取舍。 只有这样,才能在复杂的业务场景中做出正确的技术选型。

知识点测试

读完文章了?来测试一下你对知识点的掌握程度吧!

评论区

使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。

如果评论系统无法加载,请确保:

  • 您的网络可以访问 GitHub
  • giscus GitHub App 已安装到仓库
  • 仓库已启用 Discussions 功能